#!/usr/bin/env node

const child_process = require('child_process');
const fs = require('fs');
const path = require('path');
const { PerformanceObserver, performance } = require('perf_hooks');

const better_sqlite3 = require('better-sqlite3');
const chokidar = require('chokidar');
// This library is terrible, too much magic, hard to understand interface,
// does not do some obvious basics.
const commander = require('commander');
const git_url_parse = require("git-url-parse");

const cirodown = require('cirodown');
const cirodown_nodejs = require('cirodown/nodejs');

const CIRODOWN_EXT = '.ciro';
const OUT_DIRNAME = 'out';
const DEFAULT_IGNORE_BASENAMES = [
  '.git',
  '.sass-cache',
  'node_modules',
  OUT_DIRNAME,
];
const DEFAULT_IGNORE_BASENAMES_SET = new Set(DEFAULT_IGNORE_BASENAMES);
const ENCODING = 'utf8';
// https://cirosantilli.com/cirodown#index-files
const INDEX_FILE_BASENAMES_NOEXT = new Set([
  'README',
  'index',
]);
const IO_RENAME_MAP = {};
for (let i of INDEX_FILE_BASENAMES_NOEXT) {
  IO_RENAME_MAP[i] = 'index';
}

class SqliteIdProvider extends cirodown.IdProvider {
  constructor(db, ids_table_name) {
    super();
    this.delete_stmt = db.prepare(`DELETE FROM '${ids_table_name}' WHERE path = ?`);
    this.get_stmt = db.prepare(`SELECT path,ast_json FROM ${ids_table_name} WHERE id = ?`);
  }

  clear(input_path_noext_renamed) {
    this.delete_stmt.run(input_path_noext_renamed);
  }

  get(id) {
    let get_ret = this.get_stmt.get(id);
    if (get_ret === undefined) {
      return undefined;
    } else {
      const ast = cirodown.AstNode.fromJSON(get_ret.ast_json);
      return [get_ret.path, ast];
    }
  }
}

/** Report an error with the CLI usage and exit in error. */
function cli_error(message) {
  console.error(`error: ${message}`);
  process.exit(1);
}

function cmd_get_stdout(cmd, args=[], options={}) {
  if (!('dry_run' in options)) {
    options.dry_run = false;
  }
  let out;
  const cmd_str = ([cmd].concat(args)).join(' ');
  console.log(cmd_str);
  if (!options.dry_run) {
    out = child_process.spawnSync(cmd, args);
  }
  let ret;
  if (options.dry_run) {
    ret = '';
  } else {
    if (out.status != 0) {
      throw `cmd: \n${cmd_str}\nstdout: ` +
            `${out.stdout.toString(ENCODING)}\nstderr: ` +
            `${out.stderr.toString(ENCODING)}\n`;
    }
    ret = out.stdout.toString(ENCODING);
  }
  return ret;
}

function convert_files_in_directory(input_path, options, convert_input_options) {
  for (const one_path of walk_files_recursively(input_path, DEFAULT_IGNORE_BASENAMES_SET)) {
    convert_path_to_file(one_path, options, convert_input_options);
  }
}

/** Extract IDs from all input files into the ID database, without fully conveting. */
function convert_files_in_directory_extract_ids(input_path, options, convert_input_options) {
  convert_files_in_directory(
    input_path,
    cirodown.clone_and_set(options, 'render', false),
    convert_input_options
  );
}

/** Convert input from a string to output and return the output as a string.
 *
 * Wraps cirodown.convert with CLI usage convenience.
 *
 * @param {String} input
 * @param {Object} options - options to be passed to cirodown.convert
 * @param {Object} convert_input_options - control options for this function,
 *                 not passed to cirodown.convert. Also contains some returns:
 *                 - {bool} had_error
 *                 - {Object} extra_returns
 * @return {String}
 */
function convert_input(input, options, convert_input_options={}) {
  const new_options = Object.assign({}, options);
  if ('input_path_noext_renamed' in convert_input_options) {
    new_options.input_path = convert_input_options.input_path_noext_renamed;
  }
  if ('db' in convert_input_options) {
    new_options.id_provider = new SqliteIdProvider(
      convert_input_options.db,
      convert_input_options.ids_table_name
    );
  }
  if ('title' in convert_input_options) {
    new_options.title = convert_input_options.title;
  }
  new_options.extra_returns = {};
  let output = cirodown.convert(input, new_options, new_options.extra_returns);
  if (convert_input_options.cli_options.showTokens) {
    console.error('tokens:');
    console.error(JSON.stringify(new_options.extra_returns.tokens, null, 2));
    console.error();
  }
  if (convert_input_options.cli_options.showAst) {
    console.error('ast:');
    console.error(JSON.stringify(new_options.extra_returns.ast, null, 2));
    console.error();
  }
  for (const error of new_options.extra_returns.errors) {
    console.error(error.toString(convert_input_options.input_path));
  }
  convert_input_options.extra_returns = new_options.extra_returns;
  if (new_options.extra_returns.errors.length > 0) {
    convert_input_options.had_error = true;
  }
  return output;
}

/** Convert .ciro to output format and save it to a file instead of returning as a string.
 *
 * The output file name is derived from the input file name with the output extension.
 */
function convert_path_to_file(input_path, options, convert_input_options={}) {
  let input_path_parse = path.parse(input_path);
  if (input_path_parse.ext === CIRODOWN_EXT) {
    let message_prefix;
    if (options.render) {
      message_prefix = 'convert';
    } else {
      message_prefix = 'extract_ids';
    }
    let message = `${message_prefix} ${input_path}`;
    console.log(message);
    let t0 = performance.now();
    let output = convert_path(input_path, options, convert_input_options);
    let t1 = performance.now();
    console.log(`${message} finished in ${t1 - t0} ms`);
    let name = input_path_parse.name;
    if (input_path_parse.name in IO_RENAME_MAP) {
      name = IO_RENAME_MAP[name];
    }
    let output_path = path.join(input_path_parse.dir, name) + '.html';
    if (convert_input_options.cli_options.outdir) {
      fs.mkdirSync(convert_input_options.cli_options.outdir, {recursive: true});
      output_path = path.join(convert_input_options.cli_options.outdir, output_path);
    }
    fs.writeFileSync(output_path, output);
  }
}

/** Convert input from a path to string output and return the output as a string.
 *
 * @return {String}
 */
function convert_path(input_path, options, convert_input_options) {
  if (!('multifile' in convert_input_options)) {
    convert_input_options.multifile = false;
  }
  let new_options = Object.assign({}, options);
  let new_convert_input_options = Object.assign({}, convert_input_options);
  let input = fs.readFileSync(input_path, new_convert_input_options.encoding);
  let input_path_parse = path.parse(input_path);
  let input_path_basename_noext = input_path_parse.name;
  let input_path_noext_renamed;

  // https://cirosantilli.com/cirodown#the-id-of-the-first-header-is-derived-from-the-filename
  let toplevel_id;
  if (INDEX_FILE_BASENAMES_NOEXT.has(input_path_basename_noext)) {
    toplevel_id = path.basename(path.dirname(path.resolve(input_path)));
  } else {
    toplevel_id = input_path_basename_noext;
  }
  new_options.toplevel_id = toplevel_id;
  let input_path_basename_noext_renamed;
  if (input_path_basename_noext in IO_RENAME_MAP) {
    input_path_basename_noext_renamed = IO_RENAME_MAP[input_path_basename_noext];
  } else {
    input_path_basename_noext_renamed = input_path_basename_noext
  }
  input_path_noext_renamed = path.join(input_path_parse.dir, input_path_basename_noext_renamed);
  new_convert_input_options.input_path_noext_renamed = input_path_noext_renamed;
  let output = convert_input(input, new_options, new_convert_input_options);
  if ('db' in convert_input_options) {
    const ids = new_convert_input_options.extra_returns.ids;
    const insert_stmt = convert_input_options.db.prepare(
      `INSERT INTO '${convert_input_options.ids_table_name}'
      (id, path, ast_json) VALUES (?, ?, ?);`
    );
    for (const id in ids) {
      const ast = ids[id];
      insert_stmt.run(id, ast.input_path, JSON.stringify(ast));
    }
  }
  if (new_convert_input_options.had_error) {
    convert_input_options.had_error = true;
  }
  return output;
}

/** https://stackoverflow.com/questions/5827612/node-js-fs-readdir-recursive-directory-search
 *
 * @param {Set} skip_basenames
 */
function* walk_files_recursively(file_or_dir, skip_basenames) {
  if (fs.lstatSync(file_or_dir).isDirectory()) {
    const dirents = fs.readdirSync(file_or_dir, {withFileTypes: true});
    for (const dirent of dirents) {
      if (!skip_basenames.has(dirent.name)) {
        const res = dirent.name;
        if (dirent.isDirectory()) {
          yield* walk_files_recursively(res, skip_basenames);
        } else {
          yield res;
        }
      }
    }
  } else {
    yield file_or_dir;
  }
}

// CLI options.
commander.option('--body-only', 'output only the content inside the HTLM body element', false);
commander.option('--dry-run', "don't run most external commands, see: https://github.com/cirosantilli/linux-kernel-module-cheat/tree/6d0a900f4c3c15e65d850f9d29d63315a6f976bf#dry-run-to-get-commands-for-your-project", false);
commander.option('--help-macros', 'print the metadata of all macros to stdout in JSON format. https://cirosantilli.com/cirodown/#', false);
commander.option('--html-embed', 'http://cirosantilli.com/cirodown#html-embed', false);
commander.option('--html-single-page', 'http://cirosantilli.com/cirodown#html-single-page', false);
commander.option('--no-html-x-extension', 'http://cirosantilli.com/cirodown#no-html-x-extension');
commander.option('--outdir <outdir>', 'if the output would be saved to a file e.g. when building a directory, use this directory as the root');
commander.option('--output-format <output-format>', 'output format');
commander.option('--publish', 'http://cirosantilli.com/cirodown#publish', false);
commander.option('-P, --publish-commit <commit-message>', 'http://cirosantilli.com/cirodown#publish-commit');
commander.option('--show-ast', 'print the AST to stderr', false);
commander.option(
  '--show-ast-inside',
  'print the AST to stderr from inside convert before it returns. ' +
    'Useful to debug the program if conversion blow up on the next stages.',
  false
);
commander.option('--show-parse', 'print parsing internals to stderr', false);
commander.option('--show-tokenize', 'print tokenization internals to stderr', false);
commander.option('--show-tokens', 'print the token stream to stderr', false);
commander.option(
  '--show-tokens-inside',
  'print the token stream to stderr from inside convert before it returns. ' +
    'Useful to debug the program if conversion blow up on the next stages. ' +
    'Also adds token index to the output, which makes debugging the parser ' +
    'way easier.',
  false
);
commander.option('--watch', 'http://cirosantilli.com/cirodown#watch', false);
let inputPath;
commander.arguments(
  '[input_path]',
  undefined,
  'http://cirosantilli.com/cirodown#cirodown-executable',
).action(function (input_path) {
  inputPath = input_path;
});
commander.parse(process.argv);

// Action.
if (commander.helpMacros) {
  console.log(JSON.stringify(macro_list_to_macros(), null, 2));
} else {
  let input;
  let title;
  let output;
  let css_path;
  if (commander.htmlEmbed) {
    css_path = cirodown_nodejs.PACKAGE_OUT_CSS_EMBED_PATH;
  } else {
    css_path = cirodown_nodejs.PACKAGE_OUT_CSS_PATH;
  }
  let publish = commander.publish || commander.publishCommit !== undefined;
  let html_x_extension;
  if (publish) {
    // GitHub pages target is the only one for now.
    html_x_extension = false;
  } else {
    html_x_extension = commander.htmlXExtension;
  }
  let options = {
    body_only: commander.bodyOnly,
    css: fs.readFileSync(css_path, ENCODING),
    html_embed: commander.htmlEmbed,
    html_x_extension: html_x_extension,
    html_single_page: commander.htmlSinglePage,
    read_include: function(input_path) {
      return fs.readFileSync(input_path + CIRODOWN_EXT, ENCODING);
    },
    render: true,
    show_ast: commander.showAstInside,
    show_parse: commander.showParse,
    show_tokens: commander.showTokensInside,
    show_tokenize: commander.showTokenize,
  };
  let convert_input_options = {
    cli_options: commander,
    encoding: ENCODING,
    had_error: false,
    multifile: true,
  };
  if (inputPath === undefined && (publish || commander.watch)) {
    inputPath = '.';
  }
  if (inputPath === undefined) {
    title = 'stdin';
    input = fs.readFileSync(0, ENCODING);
    convert_input_options.multifile = false;
    output = convert_input(input, options, convert_input_options);
  } else {
    if (!fs.existsSync(inputPath)) {
      cli_error(`input_path does not exist: "${inputPath}"`);
    }
    let input_path_is_file;
    let outdir;
    if (fs.lstatSync(inputPath).isFile()) {
      input_path_is_file = true;
      outdir = OUT_DIRNAME;
    } else {
      input_path_is_file = false;
      outdir = path.join(inputPath, OUT_DIRNAME);
    }

    // Setup the ID database.
    fs.mkdirSync(outdir, {recursive: true});
    const db_path = path.join(outdir, 'db.sqlite3');
    const ids_table_name = 'ids';
    const db = new better_sqlite3(db_path);
    if (
      db.prepare(`SELECT name
      FROM sqlite_master
      WHERE type='table' AND name='${ids_table_name}'
      `).get() === undefined
    ) {
      db.prepare(`
        CREATE TABLE '${ids_table_name}' (
          id TEXT PRIMARY KEY,
          path TEXT,
          ast_json TEXT
        )`
      ).run();
    }
    convert_input_options.db = db;
    convert_input_options.ids_table_name = ids_table_name;

    if (commander.watch) {
      if (publish) {
        cli_error('--publish and --watch are incompatible');
      }
      if (!input_path_is_file) {
        convert_files_in_directory_extract_ids(inputPath, options, convert_input_options);
      }
      let watcher = chokidar.watch(inputPath, {ignored: DEFAULT_IGNORE_BASENAMES});
      watcher.on('change', function(path) {
        convert_path_to_file(path, options, convert_input_options);
      }).on('add', function(path) {
        convert_path_to_file(path, options, convert_input_options);
      });
    } else {
      if (input_path_is_file) {
        if (publish) {
          cli_error('--publish must take a directory as input, not a file');
        }
        convert_input_options.multifile = false;
        output = convert_path(inputPath, options, convert_input_options);
      } else {
        let actual_input;
        let publish_branch;
        let publish_dir = path.join(outdir, 'publish');
        let remote_url;
        let src_branch;
        let cmd_options = {
          dry_run: commander.dryRun,
        }

        if (publish) {
          // Clone the source to ensure that only git tracked changes get built and published.
          if (!fs.existsSync(path.join(inputPath, '.git'))) {
            cli_error('--publish must point to the root of a git repository');
          }
          remote_url = cmd_get_stdout('git', ['-C', inputPath, 'config', '--get', 'remote.origin.url'], cmd_options).slice(0, -1);
          src_branch = cmd_get_stdout('git', ['-C', inputPath, 'rev-parse', '--abbrev-ref', 'HEAD'], cmd_options).slice(0, -1);
          if (commander.dryRun) {
            remote_url = 'git@github.com:cirosantilli/cirodown.git';
            src_branch = 'master';
          }
          const parsed_remote_url = git_url_parse(remote_url);
          if (parsed_remote_url.source !== 'github.com') {
            cli_error('only know how  to publish to origin == github.com currently, please send a patch');
          }
          let remote_url_path_components = parsed_remote_url.pathname.split(path.sep);
          if (remote_url_path_components[2].startsWith(remote_url_path_components[1] + '.github.io')) {
            publish_branch = 'master';
          } else {
            publish_branch = 'gh-pages';
          }
          if (src_branch === publish_branch) {
            cli_error(`source and publish branches are the same: ${publish_branch}`);
          }
          fs.mkdirSync(publish_dir, {recursive: true});
          if (commander.publishCommit !== undefined) {
            cmd_get_stdout('git', ['-C', inputPath, 'add', '-u'], cmd_options);
            cmd_get_stdout('git', ['-C', inputPath, 'commit', '-m', commander.publishCommit], cmd_options);
          }
          if (!fs.existsSync(path.join(publish_dir, '.git'))) {
            cmd_get_stdout('git', ['clone', '--recursive', inputPath, publish_dir], cmd_options);
          } else {
            cmd_get_stdout('git', ['-C', publish_dir, 'checkout', '--', '.'], cmd_options);
            cmd_get_stdout('git', ['-C', publish_dir, 'clean', '-x', '-d', '-f'], cmd_options);
            cmd_get_stdout('git', ['-C', publish_dir, 'pull'], cmd_options);
            cmd_get_stdout('git', ['-C', publish_dir, 'submodule', 'update', '--init'], cmd_options);
          }

          // Set some variables especially for publishing.
          actual_input = publish_dir;
          commander.outdir = path.join(publish_dir, OUT_DIRNAME, 'publish');
        } else {
          actual_input = inputPath;
        }

        // Do the actual conversion.
        convert_files_in_directory_extract_ids(actual_input, options, convert_input_options);
        convert_files_in_directory(actual_input, options, convert_input_options);

        // Publish the converted output if build succeeded.
        if (publish && !convert_input_options.had_error) {
          // Push the original source.
          cmd_get_stdout('git', ['-C', inputPath, 'push'], cmd_options);
          if (!fs.existsSync(path.join(commander.outdir, '.git'))) {
            cmd_get_stdout('git', ['-C', commander.outdir, 'init'], cmd_options);
            if (publish_branch !== 'master') {
              // https://stackoverflow.com/questions/42871542/how-to-create-a-git-repository-with-the-default-branch-name-other-than-master
              cmd_get_stdout('git', ['-C', commander.outdir, 'checkout', '-b', publish_branch], cmd_options);
            }
            cmd_get_stdout('git', ['-C', commander.outdir, 'remote', 'add', 'origin', remote_url], cmd_options);
            cmd_get_stdout('git', ['-C', commander.outdir, 'fetch', 'origin'], cmd_options);
            cmd_get_stdout('git', ['-C', commander.outdir, 'reset', `origin/${publish_branch}`], cmd_options);
          }
          gemfile_content = "gem 'github-pages', group: :jekyll_plugins\n";
          if (!commander.dryRun) {
            fs.writeFileSync(path.join(commander.outdir, 'Gemfile'), gemfile_content);
          }
          cmd_get_stdout('git', ['-C', commander.outdir, 'add', '.'], cmd_options);
          source_commit = cmd_get_stdout('git', ['-C', inputPath, 'log', '-n1', '--pretty=%H', src_branch], cmd_options).slice(0, -1);
          if (commander.dryRun) {
            source_commit = '0000111122223333444455556666777788889999';
          }
          cmd_get_stdout('git', ['-C', commander.outdir, 'commit', '-m', source_commit], cmd_options);
          cmd_get_stdout('git', ['-C', commander.outdir, 'push', 'origin', `${publish_branch}:${publish_branch}`], cmd_options);
        }
      }
    }
  }
  if (output !== undefined) {
    console.log(output);
  }
  if (!commander.watch) {
    process.exit(convert_input_options.had_error);
  }
}
